ARM 汇编

Cortex-M 启动文件 中,我们简略的介绍了 ARM 汇编的几个重要的语法。本章将会对 ARM 汇编进行详细的描述。

简介

Intel 系列 CPU 是 CISC(Complex Instruction Set Computing)复杂指令集处理器,拥有更加庞大、丰富的指令集,以及更加复杂的操作和寻址方式,寄存器更少;ARM 是 RISC(Reduced Instruction Set Computing)精简指令集处理器,寄存器数量多,指令集简单,基于寄存器架构,使用 Load/Store 结构访问存储器。

RISC-V 和 Mips 也是 RISC 架构的。

RISC 相对于 CISC 来说力图各 Opcode 的长度相等。最开始时,其所有指令长度都是 4 个字节。这也是在 ARM 链接文件 中段地址定义强调 4 字节对齐的意义。RISC 可以更快的被执行,减少了每一条指令占用 CPU 的时钟周期。比如,在 CISC 中,可以用 MULT A,B 一条指令完成硬件底层的取值、乘法、存回;在 RISC 中,则需要通过基础动作 LOADPRODSTORE 等,结合寄存器中转来实现。

ARM 指令集又分为不同的版本:ARM 系列和 Thumb 系列。ARM 指令集是最初的 32 位指令集,每条指令固定位 32 bit。其性能强劲但是代码密度低,占用空间大。Thumb 是为了节省成本的产物,其为 16 位的指令集,但是性能略差。

Thumb-2 是 16 位和 32 位的混合体,处理器可以根据需要自动切换指令长度;ARM 64(AArch 64)是随着 ARM v8 架构引入的全新 64 位指令集。其重新设计了指令集,为 64 位的。其和之前的 32 位架构分离,但是可以通过兼容模式运行 32 位程序。

指令集本质上是一套规范,定义了处理器可以执行的操作和二进制编码形式,使得遵循统一指令集的的程序可以在不同 CPU 上保持兼容。汇编语言是指令集的可读形式,用助记符和符号表示机器指令。指令集链接了架构和硬件实现。架构定义了整体设计和寄存器、异常、内存模型等规则,指令集把这些规则具体化为可执行操作,CPU 按照指令集规范去执行逻辑。

ARM 架构同样作为一套规范,规定了处理器设计。CPU 按照架构实现硬件逻辑,从而实现对指令集的支持。ARM 先后提出了 ARM v4 - ARM v9 架构。ARM v4 支持基本的 ARM 指令和 Thumb 指令(v4T),知道 ARM v7 分为 A/R/M 三个架构,分别表示 Application、Real-time、Microcontroller,针对 Cortex-A、Cortex-R、Cortex-M 三个处理器。ARM v7 支持 Thumb-2 指令集。ARM v8 引入了 64 位支持(aarch 64),ARM v9 在安全性、机器学习上进行优化。Cortex 系列是 ARM 架构的具体 CPU 核实现。

关于架构、CPU 和指令集的关系。架构本身在定义行为,其不关注内部电路的实现,只要求外部表现一致。CPU 则是这个契约的具体硬件实现。而指令集是这个行为对外暴露的操作的集合。即:指令集是规定的用于操作的接口,CPU 需要能够提供这些接口。而架构规定了这些接口满足的语义规则和约束条件,确保不同厂商实现的 CPU 在执行同一指令集时得到一致的行为结果。

下表表示 ARM 架构和处理器之间的部分对应关系。

数据类型

ARM 中数据可以分为有符号和无符号两种。按照存储空间可以分为字、半字、字节。一个字节 8 位,一个半字 16 位,一个字 32 位。如下图所示。

内存中的字节序有两种,小端(Little-Endian,LE)和大端(Big-Endian,BE)。x86 采用LE,ARM 在 v3 之前采用 BE,之后可以设置切换,在 CPSR(程序状态寄存器)的第九位 E(Endian)控制。

ARM 寄存器

ARM 寄存器的数量取决于 ARM 架构版本。通常,除了 ARM v6-M 和 ARM v7-M 之外,有 30 个 32 位的通用寄存器。无论在什么模式下,都有可以访问到的寄存器 R0-R15,其中前 12 个位通用寄存器,后四个是具有特殊用途的。如下图所示。

R0 - R12 通常存放在运算过程中产生的临时数据等。ARM 架构的调用约定中规定,参数的前 4 个存储在 R0 - R3 中,之后的存储在栈中。在图中,R11(FP)用于存储系统函数调用值,也就是函数的栈帧,SP(R13)用于存储栈顶,LR(R14)用于存储函数的返回地址,PC(R15)在 ARM 模式下存储当前指令地址+8、Thumb 存储当前地址+4、x86 存储下一条将要执行的指令地址,CPSR 用于存储当前程序的状态。具体用途如下。

其中 N/Z/C/V 位用于表示当前汇编行的计算结果状态。N 表示计算结果为负数,Z 为计算结果为 0,C 为计算结果进位,V 表示计算结果溢出。

ARM 指令集格式

ARM 指令集的基本格式为:

MNEMONIC{S} {condition} {Rd}, Operand1, Operand2

其中:

  • MNEMONIC 为指令集的短名字,即助记符,比如 MOVLDR 等;
  • {S} 为一个可选的后缀。如果加了 S 后缀,则 CPSR 寄存器中相关的位被更新为操作结果;
  • {condition} 为执行指令需要满足的条件;
  • {Rd} 为存储计算结果的寄存器;
  • Operand1 为第一操作数,可以为寄存器也可以是立即数;
  • Operand2 为第二操作数(可选),可以是立即数或具有可选移位的寄存器。

ARM 寻址方式

立即寻址

mov r0, #1234

相当于 r0 = #1234。由 # 开头表示以 16 进制时以 0x 开头。

寄存器寻址

mov r0, r1

执行后, r0 = r1

寄存器移位寻址

寄存器移位寻址支持以下寻址操作:

  • LSL:逻辑左移,移位后寄存器空出的低位补 0;
  • LSR:逻辑右移,移位后寄存器空出的高位补 0;
  • ASR:算术右移,移位时符号位不变。如果源操作数为整数,则移位后空出的高位补 0,否则补 1;
  • ROR:循环右移,移位后移出的低位填入空出的高位;
  • RRX:带拓展的循环右移,操作数右移一位用 CPSR 寄存器中 C 位的值填充。

如,mov r0, r1, lsl #2 相当于 r0 = r1<<2 = r1*4

寄存器间接寻址

ldr r0, [r1]

相当于 r0 = *r1

基址寻址

ldr r0, [r1, #-4]

相当于 r0 = *(r1-4)

多寄存器寻址

ldmia r0, {r1, r2, r3, r4}

LDM 是数据加载指令,后缀 IA 表示执行完加载操作后,R0 寄存器的值自增。上面的汇编相当于r1 = [r0], r2 = [r0+#4], r3 = [r0+#8], r4 = [r0+#12]

堆栈寻址

需要特定的指令完成:

stmfd sp!, {r1~r7, lr} 表示将 r1~r7, lr 压栈,多用于保存子程序现场;

ldmfd sp!, {r1~r7, lr} 表示将 r1~r7, lr 出栈。

块拷贝寻址

可以实现连续地址数据从寄存器的某一位置拷贝到另一位置。如:

ldmia r0!, {r1-r3} 表示从 r0 指向的地址的值放到 r1~r3 中;

stmia r0!, {r1-r3 表示将 r1~r3 的值取出,放到 r0 指向的位置。

相对寻址

如:

bl .lc0
	; ...
	
.lc0:
	; ...

bl 将会直接跳到 .lc0 处。

ARM 指令

ARM 汇编指令包括存储加载类指令集,数据处理类指令集,分支跳转类指令集,程序状态寄存器访问指令以及协处理器类指令集等。

存储加载类指令

由于 ARM 处理器采用了统一编址技术,因而对外围 I/O,程序数据的访问都要通过加载/存储(Load/Store)指令来进行。ARM 的加载/存储指令(LDR,STR)可以实现字、半字、无符号、有符号字节操作。

批量加载/存储(LDM,STM)可以实现一条指令加载存储多个存储器的内容,加载效率大为提高,一般用来传递参数和复制数据。

ARM 采用 RISC 架构,CPU 本身不能直接读取内存,而需要先将内存中内容加载入 CPU 中通用寄存器中才能被 CPU 处理。ldr/str 组合用来实现 ARM CPU 和内存数据交换。

  • LDR 用于从内存中读取数据加载到内存中。如 LDR R0, [R1] 表示将 R1 所指向的存储单元的内容加载到 R0 寄存器中。
  • STR 用于将寄存器中的数据保存到内存单元。如 STR R0, [R1] 表示将 R0 寄存器里面的数据保存到 R1 所指向的内存中。
  • LDM 用于将一块连续的内存单元的数据加载多个寄存器中。
  • STM 用于将多个寄存器的数据保存到一块连续的内存单元之中。

具体示例如下:

    LDR R0, =num       ; R0 = num 的地址
    LDR R1, [R0]       ; R1 = *num,读取内存值
    ADD R1, R1, #5     ; R1 = R1 + 5
    STR R1, [R0]       ; 将 R1 写回 num

数据处理类指令

该类指令包括数据传送指令 mov,算是逻辑运算符 addsubbicorr 等,比较指令 cmptst 等。

    MOV R0, #3         ; R0 = 3
    ADD R1, R0, #5     ; R1 = R0 + 5 = 8
    SUB R2, R1, #2     ; R2 = R1 - 2 = 6
    AND R3, R2, #0x0F  ; R3 = R2 & 0x0F = 6
    ORR R4, R3, #0xF0  ; R4 = R3 | 0xF0 = 0xF6
    EOR R5, R4, R3     ; R5 = R4 ^ R3 = 0xF0
    CMP R5, #0xF0      ; 比较 R5 与 0xF0, 设置 N, Z, C, V 标志
    TST R4, #0x0F      ; 按位与测试 R4 & 0x0F, 设置标志位

若在指令后添加 S,表示其按照前文中的描述更改 CPSR 寄存器的值。

分支和跳转命令

  • b:无条件跳转
  • bl:调用子程序后返回,保存返回地址到 LR 寄存器。
  • bx:跳转到寄存器的指定地址
  • beq/blt/bgt/...:分支指令,根据标志位跳转。其根据 CPSR 中的四个位确定是否跳转。具体条换规则如下。条件码为 B 后面的字符。
条件码 全称 条件
EQ Equal Z = 1
NE Not Equal Z = 0
GT Greater Than Z = 0 且 N = V
LT Less Than N ≠ V
GE Greater or Equal N = V
LE Less or Equal Z = 1 或 N ≠ V
CS/HS Carry Set / Unsigned Higher or Same C = 1
CC/LO Carry Clear / Unsigned Lower C = 0
可能的示例如下:
CMP R0, #0
BEQ zero_label
B end
zero_label:
    MOV R1, #1
end:

伪指令

伪指令是汇编器用来控制汇编过程和程序布局的命令,它们不生成机器码,只是告诉汇编器如何组织代码和数据。

段定义指令

用于定义代码段、数据段等:

指令 功能 示例
.text 定义代码段,汇编器把之后的内容视为程序指令 .text
.data 定义可读写数据段 .data
.bss 定义未初始化的全局/静态数据 .bss
.rodata 定义只读数据段(通常用于字符串常量) .rodata

示例:

    .text
_start:
    MOV R0, #0      ; 代码段里的指令
    B end

    .data
num: .word 10       ; 定义数据段变量 num = 10

    .bss
buffer: .skip 64    ; 预留 64 字节空间,未初始化

数据定义指令

用于定义变量、数组、常量等:

指令 功能 示例
.word 定义 32 位数据 num: .word 10
.byte 定义 8 位数据 flag: .byte 0xFF
.half 定义 16 位数据 value: .half 0x1234
.space / .skip 分配未初始化空间 .space 16
.ascii 定义不带结束符的字符串 .ascii "Hello"
.asciz / .string 定义以 \0 结尾的字符串 .asciz "Hello\0"

示例:

    .data
value: .word 100        ; 32位整数
flag:  .byte 0xFF       ; 8位
name:  .asciz "ARM"     ; C风格字符串,结尾自动加 '\0'
array: .space 16        ; 预留16字节空间

符号和全局指令

指令 功能 示例
.global 声明符号为全局,可被其他文件访问 .global _start
.extern 声明外部符号,由其他文件提供 .extern printf
.equ / .set 定义常量符号 BUFFER_SIZE .equ 64

示例:

    .global _start       ; 入口符号可被链接器识别
BUFFER_SIZE .equ 64      ; 定义常量

汇编控制器指令

指令 功能 示例
.align 对齐地址(通常按 2^n 字节对齐) .align 4
.org 设置当前位置计数器(PC)到指定地址 .org 0x1000
.end 汇编文件结束标记 .end
示例:
    .text
    .align 4           ; 4字节对齐
_start:
    MOV R0, #0

    .org 0x2000        ; 强制下一条数据在 0x2000 地址开始
data_at_0x2000: .word 0x1234

在以上的汇编指令之外,还有基于这些基础指令的拓展,比如不同的数据 Load/Store 方式、移位方式等。在此不再赘述。

Hello, ARM

本节通过一个实例介绍 ARM 汇编是如何工作的。

首先,准备好 ARM 64 的编译器,如 clang,这是 macOS 中自带的编译器。新建一个文件,名为 main.S

编写以下内容:

.text
    .file   "main.c"
    .global  main     
    .p2align    2

.text 表示进入代码段,下面的描述为程序指令;.file 告诉汇编器/调试器这段汇编来自哪个文件,这里伪装为 main.c。他不影响生成的机器码,是给 gdb 这样的调试器使用的。global 定义了一个符号:main 为全局符号。这意味着链接器可以在其他文件中访问 main,并且他是程序入口点。对应 C 程序中的 int main()p2align n 表示按照 2n2^n 的方式对齐,这里为对齐 4 字节。

接下来可以书写 main 函数的部分:

.type   main,@function
main:                                   // @main
// %bb.0:
    sub sp, sp, #32                     // =32
    stp x29, x30, [sp, #16]             // 16-byte Folded Spill
    add x29, sp, #16                    // =16
    mov w8, wzr
    stur    wzr, [x29, #-4]
    adrp    x0, .L.str
    add x0, x0, :lo12:.L.str
    str w8, [sp, #8]                    // 4-byte Folded Spill
    bl  printf
    ldr w8, [sp, #8]                    // 4-byte Folded Reload
    mov w0, w8
    ldp x29, x30, [sp, #16]             // 16-byte Folded Reload
    add sp, sp, #32                     // =32
    ret

我们利用 .type 定义了标签 main 为一个函数 @function。其主要命令的介绍如下。

汇编指令 C 对应 作用说明
.type main,@function int main() 声明 main 是函数
main: int main() 函数入口
sub sp, sp, #32 自动管理栈 为局部变量分配 32 字节栈空间
stp x29, x30, [sp, #16] 保存帧指针和返回地址 保存调用现场(函数栈帧)
add x29, sp, #16 设置帧指针 x29 指向当前栈帧基址
mov w8, wzr w8 = 0 将 0 存到 w8 寄存器
stur wzr, [x29, #-4] 初始化局部变量 栈上的局部变量置零
adrp x0, .L.str + add x0, x0, :lo12:.L.str 获取字符串地址 printf 参数地址
str w8, [sp, #8] 保存 w8 到栈 printf 参数传递
bl printf printf(...) 调用 printf
ldr w8, [sp, #8] 恢复 w8 printf 后获取返回值或原值
mov w0, w8 设置返回值寄存器 main 的返回值放到 w0
ldp x29, x30, [sp, #16] 恢复帧指针和返回地址 函数返回前恢复现场
add sp, sp, #32 回收栈空间 清理栈帧
ret return 返回调用者

Last modified: 2026-05-24